feat(i18n): internationalization across the API and both React apps - #1344
feat(i18n): internationalization across the API and both React apps#1344marcelo-maciel wants to merge 78 commits into
Conversation
Add SupportedCultures constant (Default/Tags/RequestMatch) in BuildingBlocks/Core/Localization and reject unsupported locale tags in UpdateUserCommandValidator; null/empty locale still passes.
Inject IStringLocalizer<SharedResources> and swap hardcoded ProblemDetails titles (Validation/Unauthorized/NotFound/BadRequest/Unexpected + the 500 Detail) for resx keys. Exception-supplied Detail messages stay raw. Docker-free handler-level tests exercise the localized 404 and 500 branches under pt-BR and en-US.
Inject IStringLocalizer<SharedResources> into UpdateUserCommandValidator and resolve the three custom WithMessage literals lazily (Func overload, so the lookup runs per validation under the request culture, not at construction). Add the keys to both resx catalogs. Built-in FluentValidation messages localize automatically via CurrentUICulture (FV ships a pt catalog) — no LanguageManager wiring needed. Adds a resx key-parity test (neutral vs .pt) and updates the Task 2 validator test to supply a localizer.
Add a Language section to the profile dropdown, mirroring the Theme section: one item per SUPPORTED locale with an active-locale check. Selecting a locale calls i18n.changeLanguage and persists it via a new updateMyProfile mutation (PUT /identity/profile). The locale travels through the mutate argument (frontend rule fullstackhero#9). Current name/phone are echoed to avoid the backend wiping FirstName/LastName on a locale-only save. onSuccess triggers a best-effort token refresh so the new locale JWT claim is minted.
Mirror the admin Task 10 switcher on the tenant dashboard: a Language
section in the profile dropdown that switches the UI locale in place,
persists it, and re-mints the JWT locale claim.
- topbar: LanguageMenuItem + Language section (preventDefault keeps the
menu open so the section label re-localizes visibly); onSelectLanguage
calls i18n.changeLanguage, persists via updateMyProfile (locale by
mutate arg), and refreshes the token best-effort onSuccess. Profile
query is not invalidated so a refetch cannot revert the switch.
- api/identity: UpdateProfileInput gains locale; the PUT body echoes it
alongside the profile-read name/phone so a locale-only save cannot wipe
FirstName/LastName (backend sets them unconditionally).
- test: switcher spec asserts PUT {locale: pt-BR} with names preserved,
in-place localization, and the token refresh firing.
…e, settings, search)
…/combobox primitives
UseRequestLocalization was configured with both AddSupportedCultures and
AddSupportedUICultures, so every negotiated request also moved
CultureInfo.CurrentCulture. In a codebase that is not written culture-aware that
is how a JSON number arrives as "1,5" or a date round-trips wrong. Localizing the
response body is the goal; shifting formatting for the whole request is not.
Note that main has no request localization at all, so pinning the formatting
culture is LESS change than negotiating it: CurrentCulture now behaves exactly as
it does today on main, and only resource lookup follows the request.
Getting there is not a single switch. RequestLocalizationMiddleware's
SetCurrentThreadCulture assigns both CurrentCulture and CurrentUICulture
unconditionally, so the culture half has to be pinned rather than left alone:
- DefaultRequestCulture now carries (InvariantCulture, configured default). The
middleware resolves the culture half as `cultureInfo ??=
DefaultRequestCulture.Culture`, which makes invariant the only reachable
value.
- SupportedCultures is null so the middleware skips culture filtering. A
one-element [InvariantCulture] list behaves the same but logs
UnsupportedCultures on every request: the middleware's parent-culture walk
bails at the empty culture name, so invariant is unmatchable by design.
With formatting out of the negotiation, the neutral `pt`/`en` entries that existed
only to widen Accept-Language matching no longer buy anything, and they made a
request resolvable to a neutral culture whose formatting comes from a
representative culture rather than a specified one. SupportedCultures.RequestMatch
is therefore gone; Tags is the single list, specific tags only, matching the
renamed catalogs.
Behaviour change worth stating plainly: a request asking only for a bare `pt` or
an unsupported variant now resolves to the configured default rather than
Portuguese. Both React apps canonicalize variants onto supported tags before
calling the API, so app traffic is unaffected; a hand-rolled client sending bare
`pt` is.
The new test drives the real middleware and pre-sets CurrentCulture to pt-BR
before invoking it, so it proves the middleware actively resets the formatting
culture rather than merely leaving an already-invariant ambient value alone.
The column shipped as unbounded `text`. A BCP-47 tag is short and bounded, so there is no reason to accept arbitrary input at the storage layer: 10 characters covers language-script-region (zh-Hant-TW), which is the longest form the platform could ever offer. Writes were already constrained to SupportedCultures.Tags by UpdateUserCommandValidator on PUT /identity/profile, so this is the storage-level backstop, not the validation. AddUserLocale is edited in place rather than stacked with an ALTER: it has never shipped in a release, it only exists on this branch. Verified with `dotnet ef migrations has-pending-model-changes` (red before the snapshot edit, green after) and by reading the generated DDL: `ALTER TABLE identity."Users" ADD "Locale" character varying(10);`
Closes the third Codex P2, the one still open on fullstackhero#1344. StartImpersonation strips the target's `locale` claim on purpose so the operator keeps reading in their own language. But the dashboard normally runs on a separate origin from admin, so it cannot read admin's persisted `i18nextLng`, and the handoff URL carried only token/tenant/expiresAt. With no claim to negotiate from and nothing in the handoff, the API culture fell through to the dashboard's own browser detection: the operator picked Português in admin and then read English error details in the impersonated session. The handoff now carries `locale`, and the dashboard adopts it before createRoot. That fixes both halves at once: the shell renders in the operator's language, and apiFetch derives Accept-Language from i18n.language so the API localizes to it too. Unsupported or absent tags are ignored, leaving normal detection in place. Chosen over adding an actor-locale claim to the token, which Codex offered as the alternative: this stays inside the two React apps and leaves the token contract and the framework culture provider untouched. `installImpersonationFromHash` becomes async for the changeLanguage await. The token install stays synchronous at the top, and main.tsx awaits the whole thing before createRoot, so the "installed before AuthProvider's first render" guarantee is unchanged. Known gap, deliberately not addressed here and named in the spec: the SignalR client builds its own requests instead of going through apiFetch, so the hub negotiate carries the browser's Accept-Language rather than the app's locale. That applies to every session, not just impersonation. The spec asserts every OTHER path carries the operator's locale, so a new channel that stops carrying it fails the test instead of slipping through.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The varchar(10) constraint had no test pinning it — reverting HasMaxLength(10) left the whole suite green, so the fix could regress silently. Asserts the model-level max length directly, following the EventingDbContextModelTests pattern for building a module DbContext without a database.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…tive The two window validators interpolated `MaxWindow.TotalDays` — a double — into a localized message. `ResourceManagerStringLocalizer`'s indexer formats arguments with `string.Format` under `CurrentCulture`, which is now pinned invariant, so a non-integral value would render with an invariant decimal separator regardless of the reader's language. Today the window is exactly 90 days, so both cultures render "90" and no output changes. The point is that the type made it culture-sensitive by construction, and this is the only place in any catalog where a placeholder argument was not an int, long, string or enum — verified by enumerating every `MessageArgs` and every `localizer["…", …]` call site. Fixed at the source rather than at the call site: `MaxWindowDays` is the int the message wants and `MaxWindow` derives from it, so the two can never disagree and no cast can truncate.
Ten modules have a hand-written parity test. Notifications does not — it has no test project at all, so the catalog this branch added to it shipped with no parity guard. Parity is exactly the invariant that fails silently: a key missing from a translated catalog falls back to the neutral English string and ships looking translated. Discovers catalogs by reflection (a type whose full name matches an embedded `.resources` manifest, i.e. the co-located `ResourcesPath = ""` convention) across every `FSH.Modules.*.dll` in the test output plus Core, then compares each culture's OWN key set with `tryParents: false` — with parent fallback on, a missing key would be answered by the neutral catalog and parity would always look perfect. Covers new modules with no new test. Asserts a floor on the number of catalogs discovered so a reflection regression fails instead of silently guarding nothing. Verified by mutation: dropping `Notifications.NotificationNotFound` from NotificationsResources.pt-BR.resx turns it red naming both the catalog and the key.
The rule still described the design this branch replaced: a negotiated CurrentCulture, neutral `.pt` catalogs, and neutrals in the supported-tag list. A contributor reading it would have re-introduced exactly what the review asked to remove. Records the UI-culture-only split and why the culture half has to be pinned rather than left alone, the specific-tags-only convention and what adding a language now involves, the requirement that placeholder arguments be culture-insensitive, and the generic parity guard. Adds a "Known behaviour" section for the three things that are deliberate and were previously only discoverable by reading code: the one-token lag of the `locale` claim, impersonation carrying the operator's language, and SignalR not carrying the app locale at all.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The dashboard side of the handoff was pinned; the producer side was not. Dropping
`params.set("locale", i18n.language)` from the dialog left every suite green, so
half the fix had no gate.
Drives Re-open (which pre-fills the user and skips the picker step) through to
Start, with `window.open` stubbed — the URL is the thing under test and the real
dashboard origin is not served in this suite. Asserts the operator's language in
both directions (pt-BR and en-US, selected via the `?culture=` detection hook) and
re-asserts token/tenant/expiresAt so the added parameter cannot quietly displace
the pre-existing contract.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Matching keys are not enough. The caller passes ONE argument list for every culture,
so a translation that consumes a different set of `{n}` placeholders than the
neutral string is broken in that culture only — never on the reviewer's machine.
`{1}` present in Portuguese but not English is the dangerous direction:
string.Format throws FormatException when the index is out of range, turning a
localized 404 into a 500 for Portuguese readers. The opposite direction silently
drops an argument the message was supposed to show.
Compares the placeholder index SETS per key, tolerating reordering (which
translation legitimately needs) and format specifiers, and stripping escaped braces
so a literal brace is not read as a placeholder.
All eleven catalog pairs currently match. Verified by mutation: adding a `{1}` to
Catalog.BrandNotFound in pt-BR fails with
"neutral uses {0} but pt-BR uses {0,1}".
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
index.html ships a static `lang="en"` in both apps and nothing ever updated it, so a Portuguese UI kept declaring itself English to screen readers, browser translation offers and hyphenation. The whole point of the feature is that the page is in the reader's language; the attribute that tells assistive tech so was left behind. An i18next `languageChanged` listener registered before init, so it covers the initial detected language as well as every switch. Also the only language signal in these apps that a stale render cannot satisfy, which makes it the right assertion target for locale specs.
… language Audit finding (concurrency lens), verified at the source. `updateMyProfile` is a GET-then-PUT with no concurrency token, and Settings > Profile invalidates the SAME ["identity","me"] key the topbar reads. So a Settings save whose read preceded the language PUT echoes the pre-switch locale back and wins if it lands second. The topbar's hydration effect then saw `persistedLocale` change and called `changeLanguage` on it — the user watched the UI revert with no error and nothing to act on. Admin has the same shape via two rapid switches landing out of network order. Hydration now stops once the user picks a language in this session. A locale chosen on another device still carries over, because that is a fresh mount with no in-session choice — pinned by hydration-guard.spec.ts. This does NOT fix the lost update itself; the server can still end up holding the old locale, which is what the `ponytail:` notes in both topbars record. The damage is bounded from "the app changed language while I was using it" to "my language did not stick across a reload". The real fix is an ETag / RowVersion with If-Match on PUT /identity/profile — a contract change to an existing endpoint, its own PR. Not pinned by a regression test: reproducing it needs an in-mount profile refetch driven through the Settings form, and the click races the language-change re-render (element detached from the DOM). Three distinct attempts, then stopped rather than paper over it with retries. Recorded as unpinned rather than presented as verified.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Both verified at the source before changing anything.
UpdateUserCommandValidatorTests: the built-in-message test asserted only
ShouldNotContain("is not a valid email address"). A blank message, a raw resource key
leaking through, or any wrong-but-non-English string all satisfied that — the exact
failures it existed to catch. Now pins the actual Portuguese text, matching the
sibling test one method up. The literal came from the runtime, not from guessing.
ExceptionSeverityClassifierTests: LocalizedUnauthorizedAccessException subclasses the
BCL type precisely so the audit severity classifier keeps mapping unauthorized access
to Warning, and that intent existed only as a code comment. Changing the base type
would have silently reclassified every unauthorized access as Error with the whole
suite green. Added for both Localized subclasses.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Audit finding (data-contract lens), verified at the source. TitleKeyFor mapped four statuses and sent everything else to "Error.Unexpected". The type-name fallback next to it looked like it covered the rest, but it only fires on ResourceNotFound — and Error.Unexpected resolves, so it never fired. Every status outside those four reported a title contradicting its own status code and its own detail. The 41 Conflict throw sites across Billing and Catalog answered Status 409 with "An unexpected error occurred" and a detail describing an ordinary business-rule conflict. Before this branch, Title was `e.GetType().Name` — imperfect, but at least status-consistent. This regressed that. TitleKeyFor now returns null for a status with no title of its own, which routes back to the exception type name, and Conflict gets a real localized title (Error.Conflict, added to both shared catalogs). Any RFC7807-aware client branching on `title` for a conflict sees a coherent value again. Pinned in both directions: Conflict renders "Conflict"/"Conflito", and an untranslated status (Locked) falls back to the type name rather than claiming the error was unexpected.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Both parity specs iterate a hand-maintained namespace array. A namespace file added without a matching entry escaped every parity assertion in the file — it could ship half-translated with the suite green, which is precisely the failure these specs exist to prevent. The backend closed the equivalent gap generically via reflection; these two did not. Verified by mutation: dropping a new JSON catalog into src/locales/en-US fails the check naming the uncovered namespace.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Thanks — and the split is the right call. I'm not arguing with it, but I've closed the review on this branch first, and I want to explain the order rather than just do it. Three of these don't respect the split boundary. The impersonation locale fix spans both apps and only makes sense as one change. The Everything below was verified at the source, and every fix has a mutation gate: I revert the fix, require the pinning test to go red, and restore the file byte-exact. Where something isn't pinned I say so. Golden Rule #4. You're right and the omission was mine. The description now lists all eighteen
It wasn't a single switch, and the reason is worth recording:
Argument formatting. Since the localizer formats with The
JWT-carried locale. Documented as known behaviour, in the description and in the rules file, together with the impersonation and SignalR caveats. Docs. Will land with the framework PR. Changed after your review, and not reviewed by anyone.
Not pinned, stated plainly: the hydration guard has no regression test. Reproducing it needs an in-mount profile refetch through the Settings form and the click races the language-change re-render. Three distinct approaches, then I stopped rather than reach for retries or a longer timeout. CI is red on |
…ssue Both topbars carry a ponytail note explaining that the persisted locale can still be lost server-side, since PUT /identity/profile is a full-representation update with no concurrency token. The real fix is now filed upstream, so the notes point at it instead of only describing it. Comment-only change; no behaviour touched.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
SSH.NET 2025.1.0 is pulled transitively by the Testcontainers packages and fails NuGet audit (NU1903, GHSA-q939-rpr3-3284 / CVE-2026-48798), which breaks restore for the whole solution under TreatWarningsAsErrors. The advisory is not introduced by this branch -- restore fails identically on main -- and the fix properly belongs to fullstackhero#1333, which is still open. The file is taken byte-for-byte from fullstackhero#1333, comment included, so both pull requests stay mergeable in either order: git merge-tree on both orderings produces a clean tree with a single SSH.NET entry, and the merged file is identical to what fullstackhero#1333 alone produces. The byte identity is what buys that, so this copy should track fullstackhero#1333 rather than diverge, and can be dropped once fullstackhero#1333 lands first. Verified with the audit left ENABLED: dotnet restore on the solution exits 0, and the full backend suite is 1891 passed / 0 failed / 1 skipped across 15 assemblies, with zero NU1903 occurrences in the log.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Two updates since the reply above, both folded into the description. The Verified with the audit left enabled, not suppressed: Two audit findings are documented rather than quietly carried.
On the split: everything from the review is settled on this branch now, so the three slices can be cut from a tree that is already correct. Say the word and the framework PR goes up first, as you asked. |
Implements internationalization (i18n) with multi-language support across the backend API and both React front-ends, following up on the discussion in #1301 (branched off v10 GA as suggested).
src/BuildingBlocks(Golden Rule #4)The previous description omitted this, which was wrong. Eighteen files, needing maintainer sign-off:
Core—Core.csproj;Exceptions/(CustomException,ForbiddenException,UnauthorizedException, and the newILocalizableMessage,LocalizedKeyNotFoundException,LocalizedUnauthorizedAccessException);Localization/(newSharedResourcesmarker +SupportedCultures+ the two shared catalogs).Web—Extensions.cs(registers and orders the localization middleware, +6 lines);Exceptions/GlobalExceptionHandler.cs;Validation/PagedQueryValidator.cs; newLocalization/(LocalizationExtensions,UserLocaleRequestCultureProvider).Jobs—Extensions.cs, one exception message.Storage—QuotaMeteredStorageService.cs, one exception message.No existing behaviour of other building blocks is altered.
src/Directory.Packages.propscarries one addition, theSSH.NETpin discussed at the end of this description; the pin this branch used to carry forSystem.Security.Cryptography.Xmlis already onmain, so nothing else in that file differs.UseRequestLocalizationsets the UI culture onlyYou asked whether UI-culture-only was considered. It is now what ships.
mainhas no request localization at all, so pinning the formatting culture is less change than negotiating it:CultureInfo.CurrentCulturebehaves exactly as it does onmaintoday, and only resource lookup follows the request. For an API whose output is JSON that is the safer default, and it makes the CA1305 question moot rather than merely bounded.It is not one switch.
RequestLocalizationMiddleware.SetCurrentThreadCultureassigns both cultures unconditionally, so the culture half has to be pinned:DefaultRequestCulturecarries(InvariantCulture, configured default). The middleware resolves the culture half ascultureInfo ??= DefaultRequestCulture.Culture, making invariant the only reachable value.SupportedCulturesisnull, so the middleware skips culture filtering entirely. A one-element[InvariantCulture]list behaves identically but logsUnsupportedCultureson every request — the middleware's parent-culture walk bails at the empty culture name, so invariant is unmatchable by design.With formatting out of the negotiation, the neutral
pt/enentries bought nothing and are gone;SupportedCultures.Tagsis the single, specific-only list. A request asking for a bareptor an unsupported variant now resolves to the configured default. Both React apps canonicalise variants onto supported tags before calling the API, so app traffic is unaffected; a hand-rolled client sending bareptgets the default.Message arguments are culture-insensitive too. The localizer formats with
string.FormatunderCurrentCulture, so adoubleorDateTimein a message would render with an invariant separator. EveryMessageArgssite and everylocalizer["…", …]call site was enumerated: allint,long,stringor enum, exceptMaxWindow.TotalDaysin the two audit-window validators, which is now anintat the source.Catalogs are named for specific cultures
SharedResources.pt.resxand the ten module catalogs are now*.pt-BR.resx, matching the front-end. Pure renames, no string changed.The asymmetry you flagged is gone, and so is the trap behind it: a future
pt-PTis no longer served Brazilian strings by parent fallback. The documented consequence is that a bareptor an unsupported variant lands on the neutral English catalog rather than on Portuguese. Adding a language is now: add the specific tag toSupportedCultures.Tags, add a*.{tag}.resxper catalog, add the JSON catalogs to both apps, and drop it from the front-endCANONmap if it was being folded into another tag..agents/rules/localization.mdrecords all of this.The
LocalecolumnThe old summary was wrong: there is no DB default. The column is nullable with
en-USas a code-level fallback, and it is nowcharacter varying(10)rather than unboundedtext— 10 covers language-script-region (zh-Hant-TW).AddUserLocalewas edited in place rather than stacked with anALTER, since it has never shipped in a release.Confirmed as you asked:
Validation.UnsupportedLocaleis wired at the write boundary.UpdateUserCommandValidatorrestrictsLocaletoSupportedCultures.Tags, onPUT /identity/profile, via the MediatorValidationBehavior. The column constraint is the storage-level backstop, not the validation.Known behaviour (documented, not bugs)
localeclaim lags a language switch by one token. The provider reads the JWT claim, so a switch reaches the API at the next token issue. The front-end persists to the profile and re-mints, so it converges; in between, the shell can be in the new language while an API error is still in the old one. The alternative is a per-request DB read on every authenticated call.apiFetch, soAccept-Languageon the negotiate is the browser's. Applies to every session, not just impersonation. Named explicitly inhandoff-locale.spec.tsso any other channel that stops carrying the locale fails the test.UseExceptionHandler()sits ahead ofUseHeroLocalization(), which in turn has to sit afterUseAuthentication()because the culture provider reads thelocaleclaim offHttpContext.User. An exception thrown by anything in between — HTTPS redirection, CORS, static files, routing — is therefore rendered in the configured default culture rather than the caller's. Endpoint handlers, where every localized exception in this codebase is actually thrown, are unaffected. Moving the exception handler below localization would leave those middlewares with noProblemDetailsat all, which is the worse trade, so this stays as documented behaviour rather than being papered over.Fixed since the last review
Three reviewer bots' P2s, plus what a five-lens adversarial pass turned up. Each was verified at the source before being treated as real, and each fix has a mutation gate — the fix is reverted, the pinning test must go red, and the file is restored byte-exact.
StartImpersonationstrips the target'slocaleclaim on purpose, and the two apps normally sit on different origins, so the handoff URL had no way to convey it and the API fell through to the dashboard's own browser detection. The handoff now carrieslocale; the dashboard adopts it beforecreateRoot, which fixes the shell andAccept-Languageat once. Chosen over an actor-locale token claim: it leaves the token contract and the framework culture provider untouched.TitleKeyForsent everything outside four statuses toError.Unexpected; the type-name fallback beside it only fires onResourceNotFound, and that key resolves, so it never fired. Every one of the 41Conflictthrow sites across Billing and Catalog answeredStatus: 409with a title contradicting its own detail. This branch regressed that — before it,Titlewas the exception type name. Unmapped statuses now fall back to the type name again, andConflictgets a real localized title.updateMyProfileis a read-modify-write with no concurrency token, and Settings › Profile invalidates the same["identity","me"]key the topbar reads, so a save whose read preceded the language PUT could echo the old locale back and win. The topbar's hydration effect then changed the UI language to it. Hydration now stops once the user chooses a language in-session; a locale set on another device still carries over on a fresh mount. The underlying lost update is a contract change toPUT /identity/profileand is tracked separately in #1359.lang="en"that nothing updated, so a Portuguese UI announced itself as English to screen readers and browser translation.ExceptionSeverityClassifierwas never exercised with theLocalized*subclasses, even though those subclass the BCL types precisely to keep audit severity classification working — changing a base type would have silently reclassified every unauthorized access with the suite green.CatalogParityTestsdiscovers every catalog by reflection and compares each culture's own key set withtryParents: false, plus the placeholder-index set per key —{1}present in one culture and not the other throwsFormatExceptionat render time, in that culture only. New module catalogs are covered without a new test.The
SSH.NETpin is carried from #1333NU1903/GHSA-q939-rpr3-3284onSSH.NET2025.1.0, pulled transitively by Testcontainers, failsrestorefor the whole solution underTreatWarningsAsErrors— onmaintoo:dotnet restore src/FSH.Starter.slnxat3f2959e6fails identically. It is not introduced here, and the fix properly belongs to #1333.Rather than leave this PR red on someone else's advisory, the pin is carried here byte-identical to #1333's version of the file, comment included. That keeps both mergeable in either order:
git merge-treeon both orderings yields a clean tree holding a singleSSH.NETentry, and the merged file is identical to what #1333 alone produces. The byte identity is what buys that — the same pin under a reworded comment conflicts. So if #1333's pin changes during review this copy should be matched rather than allowed to drift, and once #1333 lands first it can simply be dropped from here.Testing
Not pinned, and stated rather than glossed: the hydration guard has no regression test. Reproducing it needs an in-mount profile refetch driven through the Settings form, and the click races the language-change re-render (element detached from the DOM). Three distinct approaches, then stopped rather than paper over it with retries or a longer timeout.
Notes
en-USandpt-BRare held at strict key and placeholder parity, enforced by tests, so a missing or mis-arged translation fails the build instead of shipping English.codeon ProblemDetails and the new config section are public contract.